Skip to content

Let MCPServer opt out of serving subscriptions/listen - #3626

Merged
maxisbey merged 3 commits into
mainfrom
3357-listen-opt-out
Oct 2, 2026
Merged

maxisbey merged 3 commits into
mainfrom
3357-listen-opt-out

Conversation

@maxisbey

@maxisbey maxisbey commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3357.

What was wrong

MCPServer always registers a subscriptions/listen handler, and there was no supported way to leave it out.

What changes

The existing subscriptions constructor parameter accepts one more value, False. Nothing else is added: no new parameter, name or export.

mcp = MCPServer("Static", subscriptions=False)
  • The type goes from SubscriptionBus | None to SubscriptionBus | Literal[False] | None.
  • False means the server does not serve subscriptions/listen.
  • None (the default) and a bus mean what they did before.

What users will notice

Existing servers: nothing. The default is unchanged, so a server that does not pass subscriptions=False advertises and serves listen exactly as before.

A server built with subscriptions=False:

  • server/discover at 2026-07-28 reports tools.listChanged, prompts.listChanged, resources.listChanged and resources.subscribe as false.
  • A subscriptions/listen request is answered immediately with -32601 Method not found (HTTP 404 on Streamable HTTP), and no stream is opened.
  • ctx.notify_tools_changed() and the other ctx.notify_* methods still succeed and reach nobody, so handler code is the same either way.
  • Connections on earlier protocol versions behave as before.

Not included

  • Per-capability control, such as tool list changes on and resource subscriptions off. That belongs to the declared-capabilities work in Capabilities API + server/discover handler #2896; this option is all or nothing.
  • Any change to the default, to the low-level Server, or to how an open listen stream behaves.

How it was checked

  • Three new tests in tests/server/mcpserver/test_server.py, driven through an in-memory Client:
    • with subscriptions=False, discover reports the four flags false and client.listen() fails with METHOD_NOT_FOUND
    • with subscriptions=False, a tool that calls ctx.notify_tools_changed() still returns its result
    • a default server still reports the four flags true
  • The two subscriptions=False tests fail on main and pass with this change.
  • Over stateless Streamable HTTP with json_response=True: a default server acknowledges and holds the stream; with subscriptions=False discover shows the four flags false and listen returns 404 with -32601.
  • ./scripts/test passes with 100% coverage; ruff and pyright are clean.

Docs

The option is documented on the Subscriptions page.

  • A small diagram at the top of "Scaling past one process": two replicas behind a load balancer, each with its own in-memory bus. The listen stream is subscribed to one bus, a tool call publishes to the other, and nothing connects the two.
  • The two ways out behind a load balancer: give every replica a shared SubscriptionBus, or turn change notifications off.
  • A new "Turning it off" section with a small tutorial server, docs_src/subscriptions/tutorial007.py.
  • "Deploy & scale" gets a one-line link to that section.
  • This is the first page to use the mermaid fence already configured in mkdocs.yml.
    • The theme draws the diagram in the reader's browser and loads mermaid from unpkg.com to do it.
    • If that script does not load, the diagram source shows as plain text.
  • Four new tests in tests/docs_src/test_subscriptions.py cover the page's claims, including that a stream open on one server does not hear a publish on another server with its own default bus.
  • The strict docs build and markdownlint pass. The built page was rendered in a headless browser in the light and dark palettes and at phone width.

AI Disclaimer

`MCPServer` always registered a `subscriptions/listen` handler, so on the
2026-07-28 wire every server advertised `listChanged` and
`resources.subscribe` as true and held a listen stream open for any client
that asked, even when it never publishes a change. There was no supported
way to turn that off.

The existing `subscriptions` keyword now also accepts `False`. The server
then registers no listen handler: `server/discover` reports the
change-notification flags as false and a `subscriptions/listen` request is
answered with method-not-found. `ctx.notify_*` stays callable and reaches
nobody. `None` and a bus keep their meaning, so the default is unchanged.

Fixes #3357
The Subscriptions page gains a sequence diagram at the top of "Scaling past
one process": a listen stream stays on one replica, a later tool call lands
on another, and that replica's publish goes into its own in-memory bus, so
the stream never hears it. The page then gives the two ways out: a shared
`SubscriptionBus`, or no change notifications at all.

A new "Turning it off" section covers the second with a tutorial server
built with `subscriptions=False`, and replaces the one-sentence mention
higher up the page. "Deploy & scale" links to it.

This is the first page to use the mermaid fence already configured in
mkdocs.yml.
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3626.mcp-python-docs.pages.dev
Deployment https://74b810a3.mcp-python-docs.pages.dev
Commit e30e4d7
Triggered by @maxisbey
Updated 2026-10-02 13:06:53 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Re-trigger cubic

Replace the sequence diagram on the Subscriptions page with a small
flowchart: each replica is a box holding its own bus, the listen stream is
subscribed to one, the tool call publishes to the other, and a dashed
outline marks the shared bus that is missing between them.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the lowlevel wiring for the False path: Server.__init__ drops a None handler from the request table (src/mcp/server/lowlevel/server.py:463) and get_capabilities derives the four flags from whether subscriptions/listen is registered (:586), so the discover/METHOD_NOT_FOUND behaviour follows directly from the one-line change rather than any new capability code. The None and bus branches of MCPServer.__init__ produce the same objects as before, so existing callers are unaffected.

Extended reasoning...

The change widens the subscriptions parameter of MCPServer.__init__ to accept False, which skips registering the subscriptions/listen handler while keeping an in-memory bus for ctx.notify_*; it touches no auth, crypto, or input-parsing surface. The rest of the diff is docs, a new tutorial snippet, and tests. Inline findings were posted (unchecked truthy values such as True, a missing timeout in a docs test, and a missing docstring), so a human look is already signalled; this note only records what else was examined.

One optional note from this repository's REVIEW.md or CLAUDE.md checks was not posted as a comment, over this review's limit for such notes; it is on this commit's check card.

Comment thread src/mcp/server/mcpserver/server.py
first, then A publishes a marker: the marker being the first event proves B's never arrived.
"""
async with Client(_replica()) as on_a, Client(_replica()) as on_b:
async with on_a.listen(resource_subscriptions=["board://sprint", "board://backlog"]) as sub:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) A missing or delayed acknowledgment in the cross-replica docs test hangs CI rather than failing, because the listen entry has no timeout. tests/docs_src/test_subscriptions.py:356 enters on_a.listen(...) unwrapped; the client's ack wait at src/mcp/client/subscriptions.py:271 uses the session read timeout, which Client defaults to None. Only the later anext(sub) at :359 is bounded. Fix: put the async with on_a.listen(...) entry (and the two call_tool awaits at :357-358) under anyio.fail_after(5), matching the rule and the mirror tests in tests/server/mcpserver/test_server.py.

Why this was flagged

The entry point is the new test at tests/docs_src/test_subscriptions.py:355-362 driving two in-memory MCPServer replicas. Entering on_a.listen(...) at :356 blocks in src/mcp/client/subscriptions.py:272 on route.acked.wait() bounded only by session._session_read_timeout_seconds, which is None by default (src/mcp/client/client.py:298). If a regression in ListenHandler (src/mcp/server/subscriptions.py:195-228) delays or drops the ack, or the in-memory transport stalls, the test never reaches the fail_after(5) at :359 and blocks the whole run; pyproject.toml configures no global pytest timeout. The dismissal relied on lines 330 and 333 doing the same, but AGENTS.md:87 says every indefinite wait must be wrapped in anyio.fail_after(5), and the sibling tests in tests/server/mcpserver/test_server.py added by this same PR do wrap the whole client block. Remedy: enclose the listen entry and both call_tool awaits in anyio.fail_after(5).

Verification: nit. Any stall that delays the server's subscriptions/acknowledged hangs the listen entry at tests/docs_src/test_subscriptions.py:356. Entering on_a.listen(...) waits on the ack at src/mcp/client/subscriptions.py:271-272 under the session read timeout; Client.read_timeout_seconds defaults to None (src/mcp/client/client.py:298), so the wait is indefinite.

request_state_security: RequestStateSecurity | None = None,
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
subscriptions: SubscriptionBus | None = None,
subscriptions: SubscriptionBus | Literal[False] | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): callers reading the MCPServer signature get a new accepted value, Literal[False], with no docstring saying what it does. The only explanation is a comment on the private _subscriptions attribute at src/mcp/server/mcpserver/server.py:205-208, which IDE hover and API docs never show. Fix: document the subscriptions parameter on MCPServer.__init__ (or the class docstring), stating that False leaves subscriptions/listen unserved, reports every change-notification flag false at 2026-07-28, and keeps ctx.notify_* working with no listeners. __init__ had no docstring before this change, so this is a pre-existing gap the new value widens.

Why this was flagged

The change at src/mcp/server/mcpserver/server.py:184 widens a public constructor parameter to SubscriptionBus | Literal[False] | None. Neither MCPServer (src/mcp/server/mcpserver/server.py:157) nor its __init__ (line 158) carries a docstring, so the meaning of False is recorded only in the comment at lines 205-208 on the private self._subscriptions attribute. AGENTS.md requires public APIs to have docstrings; a user inspecting the signature cannot tell from the code that False turns off the listen handler and the advertised flags rather than, say, disabling the bus. On the base branch the parameter accepted only a bus or None, so the undocumented surface was narrower. Nothing fails at runtime; this is a convention nit.

Verification: nit — triggered whenever a user inspects MCPServer(...) via signature/hover rather than the docs page. src/mcp/server/mcpserver/server.py:184 widens the public constructor parameter to subscriptions: SubscriptionBus | Literal[False] | None = None, and neither class MCPServer (line 157) nor __init__ (line 158) carries a docstring. The only in-code explanation of False is the comment at lines 205-208, which is not surfaced by IDE hover.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/handlers/subscriptions.md">

<violation number="1" location="docs/handlers/subscriptions.md:82">
P3: `stream` and `tool` are first referenced by the top-level edges `lb --> stream` and `lb --> tool` before the `subgraph A` / `subgraph B` blocks define them. Mermaid documents that a node's first reference determines its subgraph placement, so these two nodes render outside the replica boxes — the boxes would contain only `busA`/`busB`, and the intended "listen stream inside Replica A" / "tools/call inside Replica B" grouping breaks. Declare the subgraph blocks (with the nodes inside them) before the top-level `lb -->` edges.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

```mermaid
flowchart LR
client[Client] --> lb[Load balancer]
lb --> stream

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: stream and tool are first referenced by the top-level edges lb --> stream and lb --> tool before the subgraph A / subgraph B blocks define them. Mermaid documents that a node's first reference determines its subgraph placement, so these two nodes render outside the replica boxes — the boxes would contain only busA/busB, and the intended "listen stream inside Replica A" / "tools/call inside Replica B" grouping breaks. Declare the subgraph blocks (with the nodes inside them) before the top-level lb --> edges.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/handlers/subscriptions.md, line 82:

<comment>`stream` and `tool` are first referenced by the top-level edges `lb --> stream` and `lb --> tool` before the `subgraph A` / `subgraph B` blocks define them. Mermaid documents that a node's first reference determines its subgraph placement, so these two nodes render outside the replica boxes — the boxes would contain only `busA`/`busB`, and the intended "listen stream inside Replica A" / "tools/call inside Replica B" grouping breaks. Declare the subgraph blocks (with the nodes inside them) before the top-level `lb -->` edges.</comment>

<file context>
@@ -74,26 +74,27 @@ Entering `client.listen(...)` sends the request and waits for your acknowledgmen
-    deactivate A
+flowchart LR
+    client[Client] --> lb[Load balancer]
+    lb --> stream
+    lb ~~~~ gap
+    lb --> tool
</file context>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

@maxisbey
maxisbey merged commit 6513955 into main Oct 2, 2026
43 checks passed
@maxisbey
maxisbey deleted the 3357-listen-opt-out branch October 2, 2026 13:17
tanzhongyan pushed a commit to afterclass-io/afterclass.io that referenced this pull request Oct 23, 2026
Vercel logs show 2026-07-28 clients (claude-code 2.1.30x) opening a
subscriptions/listen stream that runs until the 300s maxDuration kill,
then reconnecting: 40-64% of /api/mcp invocations ended that way.
mcp-use advertises listChanged: true unconditionally, the TypeScript
client auto-opens listen when it sees that flag, and the SDK keeps the
SSE stream alive with keep-alives. The in-process event bus cannot reach
other instances and the catalog is static, so those streams never
delivered a notification.

Follow the Python SDK's opt-out (modelcontextprotocol/python-sdk#3626)
in the route's POST wrapper:

- server/discover reports listChanged and subscribe as false, so the
  client skips auto-opening a listen stream.
- A subscriptions/listen that still arrives is answered at once with
  JSON-RPC -32601 over HTTP 404, which the client reports and moves past.

Every other request passes through unchanged. Tracked upstream in
modelcontextprotocol/typescript-sdk#2873.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP
tanzhongyan added a commit to afterclass-io/afterclass.io that referenced this pull request Oct 23, 2026
…ix OG image gate (#596)

* perf: cut background function invocations and fix OG image gate

Vercel usage shows /api/auth/[...nextauth] (36K/day) and
/api/assistant/status (18K/day) among the top routes, driven by
requests the browser makes without any user action:

- SessionProvider re-fetched /api/auth/session on every tab focus.
  The session is a JWT checked server-side on every request, so turn
  focus refetching off; sign-in and sign-out still update it.
- React Query refetched every stale query on window focus, adding
  /api/trpc batches. Default it off; mutations already invalidate what
  they change. userBids.listMine opts back in because the assistant and
  MCP agents can change bids from another tab.
- AssistantProvider fetched /api/assistant/status on every route
  change. Fetch on auth status changes instead (this still covers the
  client-side login it was written for); the open-refetch re-asks
  before each use.

The course and professor OG images are cached publicly by the CDN for
a week (OG_IMAGE_HEADERS), but gated on the viewer's session. With a
domain set private, an image drawn for a signed-in viewer was then
served to anonymous viewers. Gate on isDataPublic alone, since the
cache key carries no cookies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP

* fix(assistant): ignore stale status responses after an auth change

The status fetch now runs on auth status changes only. A signed-out
request still in flight at login could resolve after the signed-in one
and overwrite it, and no later route change would repair it. Drop
responses from an effect run that has been superseded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP

* perf: stop prefetching course and professor links in long lists

Production logs show /professor/[slug] and /course/[code] requests
marked Prefetch: Yes with an _rsc param and the home page as referer,
two per link, as review cards scroll into view. Together the two
routes were 62K of 172K daily invocations.

Turn prefetching off on the review card course and professor links and
on search result links. The routes' loading.tsx already covers the wait
on click, and a prefetch of a dynamic route only fetched that loading
state anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP

* fix(assistant): let only the latest status request write

The open-refetch response was unguarded, so a signed-out response still
in flight at login could overwrite the signed-in status from the auth
change refetch. Share one latest-request counter between both effects.

Also scope the list-link prefetch invariant to every ProgressLink in
each file instead of any occurrence of the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP

* fix(mcp): stop subscriptions/listen streams holding functions open

Vercel logs show 2026-07-28 clients (claude-code 2.1.30x) opening a
subscriptions/listen stream that runs until the 300s maxDuration kill,
then reconnecting: 40-64% of /api/mcp invocations ended that way.
mcp-use advertises listChanged: true unconditionally, the TypeScript
client auto-opens listen when it sees that flag, and the SDK keeps the
SSE stream alive with keep-alives. The in-process event bus cannot reach
other instances and the catalog is static, so those streams never
delivered a notification.

Follow the Python SDK's opt-out (modelcontextprotocol/python-sdk#3626)
in the route's POST wrapper:

- server/discover reports listChanged and subscribe as false, so the
  client skips auto-opening a listen stream.
- A subscriptions/listen that still arrives is answered at once with
  JSON-RPC -32601 over HTTP 404, which the client reports and moves past.

Every other request passes through unchanged. Tracked upstream in
modelcontextprotocol/typescript-sdk#2873.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP

* test(mcp): make the save-bids parity test pick its window deterministically

save-bids reads its term from the current bid window: the first active
window ordered by opensAt. The parity seed opened windows 9101 (T1) and
9102 (T2) at the same instant, so the pick fell to Postgres row order.
When 9102 came first, every bid resolved against AY202425T2-PARITY and
the golden comparison failed. It failed on this PR's CI twice and on an
unrelated Renovate PR, and one in three full local integration runs.

Open 9102 a day after 9101 so 9101 is always the current window. The
golden output does not include window dates, and the 9102 failure case
(class not in the window's term) is unchanged. Five full local
integration runs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M83Ub5YoyGHcFYWpB8nscP

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2] Allow MCPServer to disable subscriptions/listen

1 participant